【PowerShell】Outlookの.msgを解析してRedmineにチケット起票するGUIツールを作る

Outlookで受け取った問い合わせをRedmineへ起票する際、件名・本文の転記だけでなく、トラッカーや優先度、プロジェクト固有のカスタムフィールドも入力する必要があります。プロジェクトごとに項目のIDや選択肢が異なるため、固定値をスクリプトに埋め込むと使い回しにくくなります。

そこで、Redmineからプロジェクト情報をJSONで取得し、GUIの選択候補に反映してから、Outlookの.msgを解析して起票するツールをPowerShellで構成しました。

GUIではルールの編集、JSONプレビュー、明示的な起票操作ができ、GUIを使わずPowerShellから実行する方法も用意しています。本記事では導入から設定、使い方、ソースコードまでまとめます。

Windows 11 / Windows PowerShell 5.1 / Classic Outlook を前提とします。Redmineのバージョン、プラグイン、APIキーの権限で取得できる情報が変わります。GUI操作と実Redmineへの起票は利用環境に合わせて検証してください。

1. できること

機能 内容
プロジェクト情報取得 トラッカー、優先度、カテゴリ、ステータス、担当者候補、バージョン、カスタムフィールドをAPIから取得
JSONを使った設定 プロジェクト定義を保存し、次回GUI起動時に再利用
抽出ルール管理 正規表現・キーワード・固定値などをGUIで編集・追加・削除
Outlookメール読込 .msgから件名・本文・差出人情報を取得
起票内容プレビュー Redmineへ送るJSONを投稿前に確認
チケット起票 最終確認後に POST /issues.json を実行
CLIモード GUIなしでJSON作成・明示的な起票が可能
重複対策 登録済みMSGのSHA-256と登録履歴を照合

流れは次のとおりです。

Redmine REST API
    └─ プロジェクト定義(JSON) ─────┐
                                     ↓
Outlook .msg ──> ルール解析 ──> GUIの選択値と合成
                                     ↓
                         Redmine起票用JSONのプレビュー
                                     ↓
                       「Redmineへ起票」でPOST /issues.json
                                     ↓
                            チケットIDと登録履歴

プレビューだけではRedmineに登録されません。 GUIでは起票ボタン、CLIでは -CreateIssue が必要です。また、この版では .msg 内の添付ファイル自体はRedmineへアップロードしません。

2. 事前準備

2.1 動作環境

  • Windows 11 / Windows PowerShell 5.1
  • Classic Outlook(デスクトップ版)。New OutlookだけではCOM方式で .msg を読み込めません
  • Redmine REST APIの有効化と、対象プロジェクトの「チケット作成」権限
  • APIでプロジェクト情報を参照できるユーザー

.ps1 は Shift-JIS(CP932) で保存します。JSONはUTF-8、BATは掲載どおり保存してください。PowerShellスクリプトをUTF-8 BOMなしで保存すると、Windows PowerShell 5.1で日本語が文字化けすることがあります。

2.2 フォルダ構成

Redmine-MSG-Ticket-Studio/
├─ Start-RedmineGUI.bat
├─ Start-RedmineGUI.ps1
├─ Fetch-ProjectInfo.bat
├─ Fetch-ProjectInfo.ps1
├─ Run-Issue.ps1
├─ Redmine.Core.ps1
├─ rules.json
├─ sample-project-metadata.json
├─ test-mail.json
└─ tool-settings.json

project-metadata.json はAPIからプロジェクト情報を取得したときに、posted-history.json は起票履歴を保存したときに生成されます。後者は実際のチケットIDを含むため、共有範囲を制限してください。

2.3 APIキーの設定

Redmineの個人用APIキーを取得し、Windowsのユーザー環境変数に登録します。

[Environment]::SetEnvironmentVariable(
    'REDMINE_API_KEY',
    'YOUR_REDMINE_API_KEY',
    'User'
)

YOUR_REDMINE_API_KEY は自身のAPIキーに置き換えてください。設定後、PowerShellを開き直します。GUIの「APIキー」欄へ一時入力する方法もありますが、APIキーをコードや公開Gitリポジトリに記載しないでください。

3. プロジェクト情報をJSONで取得する

3.1 GUIからの取得

Start-RedmineGUI.bat を起動します。GUIには次の3つのタブがあります。

タブ 用途
① プロジェクト設定 Redmine URL・プロジェクトを入力し、APIから定義を取得、JSONを保存・読み込み
② 抽出ルール 正規表現とフィールドへの割当てを設定、行の追加・削除、保存
③ MSG解析・起票 MSGファイルを選び、候補を設定し、JSONをプレビューしてから起票

「① プロジェクト設定」で接続先URLとプロジェクト識別子(例:sample-project)を入力し、「APIから取得」を押します。読み込まれた情報はトラッカー等の選択欄に反映されます。オフラインで画面を確認したい場合は「JSONを読み込む」から sample-project-metadata.json を指定できます。ただし、サンプルのIDのまま本番起票してはいけません。

3.2 PowerShellからの取得

.\Fetch-ProjectInfo.ps1 `
  -BaseUrl 'https://redmine.example.com' `
  -Project 'sample-project' `
  -OutputPath '.\project-metadata.json'

プロジェクト情報をJSONに書き出し、GUIでそのファイルを読み込めます。接続先が社内HTTPの場合のみ -AllowHttp を指定できますが、APIキーを平文通信するためHTTPSを推奨します。

主な取得先は次のAPIです。

API 用途
GET /projects/{identifier}.json?include=trackers,issue_categories,issue_custom_fields プロジェクトと許可されたトラッカー等
GET /trackers.json トラッカーの追加情報
GET /issue_statuses.json ステータス候補
GET /enumerations/issue_priorities.json 優先度候補
GET /projects/{identifier}/memberships.json 参照可能な担当者候補
GET /projects/{identifier}/versions.json 対象バージョン
GET /custom_fields.json カスタムフィールド定義(管理者権限が必要)

RedmineのプロジェクトAPIでは trackers、issue_categories、issue_custom_fields の関連情報を取得できます。すべてのステータスが新規作成時に利用可能とは限りません。また、カスタムフィールドの詳細情報は権限不足により取得できない場合があります。

v1.1:カスタムフィールドのマッピングを分かりやすく

最初の画面では、Windowsの拡大率によってヘッダーとタブの一部が重なることがありました。v1.1ではレイアウトを見直し、カスタムフィールド名を使ってルールを追加できるようにしました。

「プロジェクト設定」タブで「APIから取得」または「JSONを読み込む」を選択すると、フィールドID、名称、形式、必須条件が一覧に表示されます。マッピングしたい行を選択し、「選択フィールドをルールへ追加」を押すと、その項目を出力先とするルールが追加されます。

例えばメール本文の「問い合わせ者:山田太郎」を「問い合わせ者」フィールドへ登録する場合、以下のように設定します。

設定 値
抽出元 Body
抽出方式 Regex
パターン (?m)^問い合わせ者[::]\s*(.+?)\s*$
グループ 1
出力先 カスタム: 問い合わせ者 (ID:12)

設定後に「ルールを保存」を押してください。実際の保存値は cf:12 です。IDは例示用で、読み込んだプロジェクト情報によって異なります。

「MSG解析・起票」タブで .msg を選択してJSONをプレビューすると、抽出結果は custom_fields の項目へ反映されます。出力先と必須項目を確認してから「Redmineへ起票」を押します。GUI表示とRedmine連携は実環境での検証が必要です。

4. メールから情報を抽出するルールを設定

「② 抽出ルール」では、メール中の文字列をどのRedmine項目へセットするか指定します。設定は rules.json に保存されるため、実装コードを書き換えずに運用ルールを変更できます。

設定 例 意味
source Body 解析対象(件名・本文・送信者など)
mode Regex 取得方法
pattern (?m)^問い合わせ者[::]\s*(.+)\s*$ 検索パターン
group 1 正規表現の取得グループ
target cf:12 RedmineのカスタムフィールドID 12へ保存
fallback $SENDER 見つからなかったときの代替値

取得方式は Regex(正規表現)、Keywords(候補語の検索)、Literal(全文または固定語の一致)、Constant(常に指定値)の4種類です。

カスタムフィールドは cf:12 のようにIDを指定します。cf:12、cf:13、cf:15 は設定例です。実際のプロジェクトJSONに記載されたIDへ変更してください。 標準項目の場合、subject、description、tracker_id、priority_id、assigned_to_id、due_date などを指定できます。

例として、メール本文が次の形式なら問い合わせ者と分類を抽出できます。

件名: 【障害】ログインできない INC-2026-001

問い合わせ者: 山田 太郎
分類: 障害

業務システムへログインできず、エラーが発生します。

抽出条件が一致しない場合の挙動は fallback に依存します。選択式カスタムフィールドでは、抽出値がRedmine側の許可値と一致することも確認してください。

5. GUIでMSGを読み込んでチケットを起票

  1. 「③ MSG解析・起票」を開き、使用するトラッカー、優先度、必要な担当者・カテゴリを選択します。
  2. 「ファイルを選択」からOutlookの .msg を指定します。
  3. 「解析プレビュー」を押し、ルールと選択値を反映したJSONを確認します。
  4. 必須項目・カスタムフィールド・起票先プロジェクトが正しいことを確認します。
  5. 「Redmineへ起票」を押し、確認ダイアログで承認すると初めてPOSTされます。
  6. 正常に登録されると、チケット番号とRedmineのURLが表示されます。

実際に送信するのは POST /issues.json です。作成JSONの構造は次のようになります(IDはダミーです)。

{
  "issue": {
    "project_id": 246,
    "tracker_id": 2,
    "subject": "【障害】ログインできない INC-2026-001",
    "description": "業務システムへログインできません",
    "custom_fields": [
      { "id": 12, "value": "山田 太郎" },
      { "id": 13, "value": "INC-2026-001" }
    ]
  }
}

選択できるステータスの一覧は、起票ユーザーの権限とトラッカーのワークフローで許可される状態遷移とは異なります。新規作成ではRedmine既定の初期ステータスを使うため、ステータス選択を空欄にする構成が無難です。

なお、登録に失敗した場合はHTTP 401/403(認証・権限)、404(URLやプロジェクトの確認)、422(必須項目や選択肢の不一致)を中心に確認します。

6. GUIなしで実行するCLIモード

CLIはJenkinsやバッチ処理の前処理として、JSONの確認だけ行う用途にも向いています。

6.1 MSGからJSONをプレビュー

.\Run-Issue.ps1 `
  -MsgPath 'C:\Mail
equest.msg' `
  -MetadataPath '.\project-metadata.json' `
  -RulesPath '.
ules.json' `
  -OutputPath '.\issue-preview.json'

ここではRedmineに登録されません。

6.2 明示的にチケットを作成

.\Run-Issue.ps1 `
  -MsgPath 'C:\Mail
equest.msg' `
  -CreateIssue `
  -TrackerId 2

起票時だけ -CreateIssue を付けます。誤登録を防ぐため、まずJSONを生成して値を確認してから実行してください。

6.3 Outlookなしでルールだけ確認

.\Run-Issue.ps1 `
  -MailJson '.  est-mail.json' `
  -MetadataPath '.\sample-project-metadata.json'

この方法ではRedmineへの起票はできず、サンプルメールのJSONを使ったマッピング確認のみ行えます。

7. 重複登録と取り扱い上の注意

ツールはMSGのSHA-256、Redmine URL、プロジェクトIDを組み合わせ、成功した起票を posted-history.json に記録します。同じ条件の再起票を抑制しますが、Redmine登録成功直後に通信断や履歴保存失敗が発生した場合には二重起票の可能性があります。 エラー後の再実行前にRedmine側を確認してください。

また、カスタムフィールド定義、メンバー名やメールの本文には業務上の情報が含まれるため、出力JSON、ログ、履歴ファイルを不用意に公開しないようにします。APIキーはGit管理せず、最小限の権限で利用します。

8. ファイル別ソースコード全文(v1.1)

以下のファイルを同じフォルダに保存すると構成を再現できます。コードは途中を省略していません。 PowerShellの .ps1 はShift-JIS(CP932)、JSONはUTF-8として保存してください。まずは例の設定で画面表示を確認し、実際に起票する前にRedmineのURL・プロジェクト・IDへ置き換えます。

Start-RedmineGUI.bat

@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0Start-RedmineGUI.ps1"
pause

Start-RedmineGUI.ps1

#Requires -Version 5.1
param([string]$Workspace=$PSScriptRoot)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
[System.Windows.Forms.Application]::EnableVisualStyles()
. (Join-Path $PSScriptRoot 'Redmine.Core.ps1')
$ErrorActionPreference='Stop'

$workspace=[IO.Path]::GetFullPath($Workspace)
$metaFile=Join-Path $workspace 'project-metadata.json'
$rulesFile=Join-Path $workspace 'rules.json'
$settingsFile=Join-Path $workspace 'tool-settings.json'
$script:metadata=$null
$script:currentMail=$null
$script:payload=$null
$script:created=$false
$script:previewMsgHash=''
$colors=@{bg='#111827'; panel='#1f2937'; input='#263548'; border='#435266'; text='#f3f4f6'; muted='#b5c2d1'; accent='#3b82f6'; danger='#b94755'; good='#168366'}
function C([string]$Hex) { return [System.Drawing.ColorTranslator]::FromHtml($Hex) }
function Label($text,$x,$y,$width=180,$height=23) {
    $o=New-Object System.Windows.Forms.Label
    $o.Text=$text;$o.Left=$x;$o.Top=$y;$o.Width=$width;$o.Height=$height
    $o.ForeColor=(C $colors.text);$o.BackColor=(C $colors.panel)
    $o.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
    return $o
}
function TextBox($x,$y,$w=200,$h=30,$multi=$false) {
    $o=New-Object System.Windows.Forms.TextBox
    $o.Left=$x;$o.Top=$y;$o.Width=$w;$o.Height=$h
    $o.BackColor=(C $colors.input);$o.ForeColor=(C $colors.text)
    $o.BorderStyle='FixedSingle';$o.Font=New-Object System.Drawing.Font('Consolas',10)
    $o.Multiline=$multi
    if($multi){$o.ScrollBars='Both';$o.WordWrap=$false}
    return $o
}
function Button($text,$x,$y,$w=140,$tone='normal') {
    $o=New-Object System.Windows.Forms.Button
    $o.Text=$text;$o.Left=$x;$o.Top=$y;$o.Width=$w;$o.Height=34
    $o.FlatStyle='Flat';$o.FlatAppearance.BorderSize=0
    $o.Font=New-Object System.Drawing.Font('Yu Gothic UI',9,[System.Drawing.FontStyle]::Bold)
    $o.ForeColor=(C '#ffffff')
    $o.BackColor = switch ($tone) { 'accent' { C $colors.accent } 'danger' { C $colors.danger } 'good' { C $colors.good } default { C $colors.input } }
    $o.Cursor=[System.Windows.Forms.Cursors]::Hand
    return $o
}
function Combo($x,$y,$w=200) {
    $o=New-Object System.Windows.Forms.ComboBox
    $o.Left=$x;$o.Top=$y;$o.Width=$w;$o.Height=30
    $o.DropDownStyle='DropDownList';$o.BackColor=(C $colors.input);$o.ForeColor=(C $colors.text)
    $o.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
    return $o
}
function Style-Grid($g) {
    $g.BackgroundColor=(C $colors.panel);$g.BorderStyle='None'
    $g.GridColor=(C $colors.border);$g.EnableHeadersVisualStyles=$false
    $g.ColumnHeadersDefaultCellStyle.BackColor=(C $colors.input)
    $g.ColumnHeadersDefaultCellStyle.ForeColor=(C $colors.text)
    $g.DefaultCellStyle.BackColor=(C $colors.panel)
    $g.DefaultCellStyle.ForeColor=(C $colors.text)
    $g.DefaultCellStyle.SelectionBackColor=(C '#245aa4')
    $g.DefaultCellStyle.SelectionForeColor=(C '#ffffff')
    $g.RowHeadersVisible=$false;$g.ColumnHeadersHeight=38;$g.RowTemplate.Height=31
    $g.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
}
function Fail($e) {
    $message=if($e -is [Management.Automation.ErrorRecord]){$e.Exception.Message}else{[string]$e}
    [System.Windows.Forms.MessageBox]::Show($message,'処理エラー','OK','Error')|Out-Null
}
function Log([string]$text) {
    $txtLog.AppendText((Get-Date -Format 'HH:mm:ss')+'  '+$text+"`r`n")
}
function Combo-Id($cb) {
    if ($cb.SelectedItem -and $cb.SelectedItem.PSObject.Properties['Id']) { return [int]$cb.SelectedItem.Id }
    return 0
}
function Set-Options($cb,$values,[string]$first='既定(指定しない)') {
    $cb.Items.Clear()
    [void]$cb.Items.Add([pscustomobject]@{Name=$first;Id=0})
    foreach ($v in @($values)) {
        if ($null -ne $v -and $v.id) { [void]$cb.Items.Add([pscustomobject]@{Name=[string]$v.name;Id=[int]$v.id}) }
    }
    $cb.DisplayMember='Name';$cb.ValueMember='Id';$cb.SelectedIndex=0
}
function Make-TextColumn($name,$header,$width) {
    $col=New-Object System.Windows.Forms.DataGridViewTextBoxColumn
    $col.Name=$name;$col.HeaderText=$header;$col.Width=$width
    return $col
}
function Make-ComboColumn($name,$header,$width,$items) {
    $col=New-Object System.Windows.Forms.DataGridViewComboBoxColumn
    $col.Name=$name;$col.HeaderText=$header;$col.Width=$width
    $col.DisplayStyle=[System.Windows.Forms.DataGridViewComboBoxDisplayStyle]::DropDownButton
    foreach($value in $items){[void]$col.Items.Add($value)}
    return $col
}
function Get-RulesFromGrid {
    $gridRules.EndEdit() | Out-Null
    $list=New-Object System.Collections.Generic.List[object]
    foreach($row in $gridRules.Rows) {
        if ($row.IsNewRow) {continue}
        $obj=[ordered]@{}
        foreach($key in @('enabled','name','source','mode','pattern','group','target','fallback')) {
            $value=$row.Cells[$key].Value
            if($key -eq 'enabled'){$obj[$key]=([string]$value -eq 'True')}
            elseif($key -eq 'group'){$obj[$key]=if([string]$value -match '^\d+$'){[int]$value}else{0}}
            else{$obj[$key]=[string]$value}
        }
        if(-not $obj.name){throw 'ルール名を入力してください。'}
        if(-not $obj.target){throw "ルール $($obj.name) の出力先が未設定です。"}
        $list.Add([pscustomobject]$obj)
    }
    return [pscustomobject]@{version=1;rules=@($list.ToArray())}
}
function Add-RuleToGrid($r) {
    $idx=$gridRules.Rows.Add()
    $row=$gridRules.Rows[$idx]
    foreach($key in @('enabled','name','source','mode','pattern','group','target','fallback')) {
        $row.Cells[$key].Value=$r.$key
    }
}
function Load-RulesGrid($data) {
    $gridRules.Rows.Clear()
    foreach($r in @($data.rules)){if($r){Add-RuleToGrid $r}}
}
function Refresh-TargetList {
    $targetCol=[System.Windows.Forms.DataGridViewComboBoxColumn]$gridRules.Columns['target']
    $choices=New-Object 'System.Collections.Generic.List[object]'
    foreach($target in @('subject','description','tracker_id','priority_id','category_id','assigned_to_id','fixed_version_id','due_date','start_date','status_id','Ignore')) {
        $display = switch($target) {
            'subject' {'件名 / subject'} 'description' {'説明 / description'}
            'tracker_id' {'トラッカーID'} 'priority_id' {'優先度ID'}
            'category_id' {'カテゴリID'} 'assigned_to_id' {'担当者ID'}
            'fixed_version_id' {'対象バージョンID'} 'due_date' {'期限 / due_date'}
            'start_date' {'開始日 / start_date'} 'status_id' {'ステータスID'}
            default {'除外 / Ignore'}
        }
        $choices.Add([pscustomobject]@{Name=$display;Value=$target})
    }
    if($script:metadata) {
        foreach($f in @($script:metadata.customFields)) {
            if($f -and $f.id) {
                $choices.Add([pscustomobject]@{Name=('カスタム: {0} (ID:{1})' -f $f.name,$f.id);Value=('cf:{0}' -f $f.id)})
            }
        }
    }
    foreach($row in $gridRules.Rows) {
        $v=[string]$row.Cells['target'].Value
        if($v -and -not @($choices | Where-Object { $_.Value -eq $v }).Count) {
            $choices.Add([pscustomobject]@{Name=('不明な項目: '+$v);Value=$v})
        }
    }
    $gridRules.EndEdit() | Out-Null
    $targetCol.DataSource=$null
    $targetCol.DisplayMember='Name';$targetCol.ValueMember='Value'
    $targetCol.DataSource=$choices.ToArray()
}
function Add-FieldMapping {
    if(-not $script:metadata) {throw '先にプロジェクトJSONを読み込んでください。'}
    if($gridFields.SelectedRows.Count -lt 1) {throw 'カスタムフィールド一覧の行を選択してください。'}
    $selected=$gridFields.SelectedRows[0]
    $id=[int]$selected.Cells['id'].Value
    $name=[string]$selected.Cells['name'].Value
    $def=@($script:metadata.customFields | Where-Object { [int]$_.id -eq $id } | Select-Object -First 1)
    if(-not $def.Count){throw '選択したフィールドがプロジェクト定義にありません。'}
    $possible=@($def[0].possible_values)
    $isSelect=($possible.Count -gt 0)
    $mode=if($isSelect){'Constant'}else{'Regex'}
    $pattern=if($isSelect){[string]$(if($possible[0] -is [string]){$possible[0]}else{$possible[0].value})}else{'(?m)^'+[regex]::Escape($name)+'[::]\s*(.+?)\s*$'}
    $rule=[pscustomobject]@{enabled=$true;name=$name;source='Body';mode=$mode;pattern=$pattern;group=1;target=('cf:'+ $id);fallback=''}
    Add-RuleToGrid $rule
    Refresh-TargetList
    $tabs.SelectedTab=$tRules
    $gridRules.ClearSelection()
    $last=$gridRules.Rows[$gridRules.Rows.Count-1]
    $last.Selected=$true
    $gridRules.FirstDisplayedScrollingRowIndex=$last.Index
    [System.Windows.Forms.MessageBox]::Show(('「'+$name+'」へのマッピングルールを追加しました。抽出方法とパターンを確認し、「ルールを保存」を押してください。'),'マッピング追加')|Out-Null
}

function Apply-Metadata($meta) {
    if(-not $meta.project -or -not $meta.project.id){throw 'プロジェクトJSONに project.id がありません。'}
    $script:metadata=$meta
    $txtUrl.Text=[string]$meta.baseUrl
    $txtProject.Text=[string]$meta.project.identifier
    if(-not $txtProject.Text){$txtProject.Text=[string]$meta.project.id}
    $lblProject.Text="PROJECT  $($meta.project.name)  (#$($meta.project.id))"
    Set-Options $cbTracker $meta.trackers 'トラッカーを選択'
    Set-Options $cbPriority $meta.priorities
    Set-Options $cbCategory $meta.categories
    Set-Options $cbStatus $meta.statuses 'Redmineの既定(推奨)'
    Set-Options $cbAssignee $meta.members
    Set-Options $cbVersion $meta.versions
    if($cbTracker.Items.Count -gt 1){$cbTracker.SelectedIndex=1}
    $gridFields.Rows.Clear()
    foreach ($f in @($meta.customFields)) {
        if(-not $f){continue}
        $possible=@()
        foreach($v in @($f.possible_values)){
            if($v -is [string]){$possible+= $v}
            elseif($v -and $v.value){$possible += [string]$v.value}
        }
        $opts=$possible -join ', '
        if(-not $opts){$opts='(未取得または自由入力)'}
        [void]$gridFields.Rows.Add($f.id,$f.name,$f.field_format,$f.is_required,$f.multiple,$opts)
    }
    Refresh-TargetList
    Log "プロジェクト情報を反映: $($meta.project.name) / トラッカー $(@($meta.trackers).Count) / カスタムフィールド $(@($meta.customFields).Count)"
    foreach($note in @($meta.warnings)){if($note){Log "注意: $note"}}
}
function Prepare-Payload {
    if(-not $script:metadata){throw '先にプロジェクト情報を取得/読込してください。'}
    if(-not $script:currentMail){
        if(-not $txtMsg.Text){throw 'MSGファイルを選択してください。'}
        $script:currentMail=Get-MailFromMsg $txtMsg.Text
    }
    $rules=Get-RulesFromGrid
    return New-RedmineIssueBody -Mail $script:currentMail -Metadata $script:metadata -Rules $rules `
      -TrackerId (Combo-Id $cbTracker) -PriorityId (Combo-Id $cbPriority) -CategoryId (Combo-Id $cbCategory) `
      -StatusId (Combo-Id $cbStatus) -AssignedToId (Combo-Id $cbAssignee) -VersionId (Combo-Id $cbVersion)
}

$form=New-Object System.Windows.Forms.Form
$form.Text='Redmine MSG Ticket Studio  |  GUI / API'
$form.Width=1250;$form.Height=900;$form.MinimumSize=New-Object System.Drawing.Size(1130,795)
$form.AutoScaleMode=[System.Windows.Forms.AutoScaleMode]::Dpi
$form.StartPosition='CenterScreen';$form.BackColor=(C $colors.bg)
$form.ForeColor=(C $colors.text);$form.Font=New-Object System.Drawing.Font('Yu Gothic UI',9)
$header=New-Object System.Windows.Forms.Panel;$header.Dock='Top';$header.Height=90;$header.BackColor=(C $colors.panel)
$layout=New-Object System.Windows.Forms.TableLayoutPanel
$layout.Dock='Fill';$layout.ColumnCount=1;$layout.RowCount=2
[void]$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Absolute,95)))
[void]$layout.RowStyles.Add((New-Object System.Windows.Forms.RowStyle([System.Windows.Forms.SizeType]::Percent,100)))
$form.Controls.Add($layout)
[void]$layout.Controls.Add($header,0,0)
$title=Label 'Redmine MSG Ticket Studio' 26 15 550 35
$title.Font=New-Object System.Drawing.Font('Yu Gothic UI',18,[System.Drawing.FontStyle]::Bold)
$header.Controls.Add($title)
$sub=Label 'プロジェクト定義 → 抽出ルール → JSONプレビュー → チケット起票' 27 52 800 27
$sub.ForeColor=(C $colors.muted);$header.Controls.Add($sub)
$tabs=New-Object System.Windows.Forms.TabControl;$tabs.Dock='Fill'
$tabs.Font=New-Object System.Drawing.Font('Yu Gothic UI',10)
$tabs.SizeMode='Fixed';$tabs.ItemSize=New-Object System.Drawing.Size(210,34)
[void]$layout.Controls.Add($tabs,0,1)
$tabs.Dock='Fill'
$header.Dock='Fill'
function Tab($name){$t=New-Object System.Windows.Forms.TabPage;$t.Text=$name;$t.BackColor=(C $colors.panel);$tabs.TabPages.Add($t);return $t}
$tConnection=Tab '① プロジェクト設定'
$tRules=Tab '② 抽出ルール'
$tPost=Tab '③ MSG解析・起票'

$tConnection.Controls.Add((Label 'Redmine URL' 20 25 150))
$txtUrl=TextBox 20 53 370;$txtUrl.Text='https://redmine.example.com';$tConnection.Controls.Add($txtUrl)
$tConnection.Controls.Add((Label 'プロジェクトID / 識別子' 410 25 190))
$txtProject=TextBox 410 53 210;$txtProject.Text='sample-project';$tConnection.Controls.Add($txtProject)
$tConnection.Controls.Add((Label 'APIキー(一時入力・保存しない)' 640 25 270))
$txtKey=TextBox 640 53 220;$txtKey.UseSystemPasswordChar=$true;$tConnection.Controls.Add($txtKey)
$chkHttp=New-Object System.Windows.Forms.CheckBox;$chkHttp.Text='HTTPを許可(非推奨)';$chkHttp.Left=875;$chkHttp.Top=58;$chkHttp.Width=190;$chkHttp.ForeColor=(C $colors.muted);$tConnection.Controls.Add($chkHttp)
$btnFetch=Button 'APIから取得' 20 105 160 'accent';$tConnection.Controls.Add($btnFetch)
$btnOpenMeta=Button 'JSONを読み込む' 190 105 170;$tConnection.Controls.Add($btnOpenMeta)
$btnSaveMeta=Button 'JSONを保存' 370 105 150;$tConnection.Controls.Add($btnSaveMeta)
$lblProject=Label 'PROJECT  未読込' 25 158 1000 30
$lblProject.Font=New-Object System.Drawing.Font('Yu Gothic UI',12,[System.Drawing.FontStyle]::Bold)
$tConnection.Controls.Add($lblProject)
$tConnection.Controls.Add((Label 'カスタムフィールド一覧(行を選択し、右のボタンでマッピングを作成)' 25 198 680))
$btnMapField=Button '選択フィールドをルールへ追加 →' 733 195 305 'accent';$tConnection.Controls.Add($btnMapField)
$gridFields=New-Object System.Windows.Forms.DataGridView
$gridFields.Location=New-Object System.Drawing.Point(25,232);$gridFields.Size=New-Object System.Drawing.Size(1080,325)
$gridFields.Anchor='Top,Left,Right,Bottom';$gridFields.AllowUserToAddRows=$false;$gridFields.ReadOnly=$true
$gridFields.MultiSelect=$false
$gridFields.AutoSizeColumnsMode='Fill';$gridFields.SelectionMode='FullRowSelect'
Style-Grid $gridFields
foreach($item in @(@('id','ID'),@('name','名前'),@('format','形式'),@('required','必須'),@('multiple','複数'),@('choices','候補値'))) { [void]$gridFields.Columns.Add($item[0],$item[1]) }
$tConnection.Controls.Add($gridFields)
$txtLog=TextBox 25 582 1080 78 $true;$txtLog.ReadOnly=$true;$txtLog.Anchor='Left,Right,Bottom';$tConnection.Controls.Add($txtLog)

$tRules.Controls.Add((Label 'ルールは上から順に適用。同じ項目には後のルールの値が優先されます。' 20 18 980 26))
$btnAdd=Button '+ ルール追加' 20 55 150 'accent';$tRules.Controls.Add($btnAdd)
$btnDelete=Button '選択行を削除' 180 55 150 'danger';$tRules.Controls.Add($btnDelete)
$btnSaveRules=Button 'ルールを保存' 340 55 150 'good';$tRules.Controls.Add($btnSaveRules)
$btnReloadRules=Button '再読み込み' 500 55 150;$tRules.Controls.Add($btnReloadRules)
$gridRules=New-Object System.Windows.Forms.DataGridView
$gridRules.Location=New-Object System.Drawing.Point(20,108);$gridRules.Size=New-Object System.Drawing.Size(1100,480)
$gridRules.Anchor='Top,Left,Right,Bottom';$gridRules.AllowUserToAddRows=$false
$gridRules.EditMode='EditOnEnter'
$gridRules.Add_DataError({param($sender,$e) $e.ThrowException=$false})
$gridRules.SelectionMode='FullRowSelect';$gridRules.MultiSelect=$true
$gridRules.AutoSizeColumnsMode='None';$gridRules.RowHeadersVisible=$false;Style-Grid $gridRules
$enableCol=New-Object System.Windows.Forms.DataGridViewCheckBoxColumn;$enableCol.Name='enabled';$enableCol.HeaderText='有効';$enableCol.Width=53;[void]$gridRules.Columns.Add($enableCol)
[void]$gridRules.Columns.Add((Make-TextColumn 'name' 'ルール名' 124))
[void]$gridRules.Columns.Add((Make-ComboColumn 'source' '抽出元' 150 @('Subject','Body','Subject+Body','SenderName','SenderEmailAddress','To','ReceivedTime')))
[void]$gridRules.Columns.Add((Make-ComboColumn 'mode' '抽出方式' 104 @('Regex','Keywords','Literal','Constant')))
[void]$gridRules.Columns.Add((Make-TextColumn 'pattern' 'パターン(Keywords は | 区切り)' 250))
[void]$gridRules.Columns.Add((Make-TextColumn 'group' 'Grp' 42))
[void]$gridRules.Columns.Add((Make-ComboColumn 'target' '出力先(名前で選択)' 255 @('subject','description','Ignore')))
[void]$gridRules.Columns.Add((Make-TextColumn 'fallback' '不一致時' 154))
$tRules.Controls.Add($gridRules)
$help=Label '出力先はRedmine項目名で選択できます。カスタムフィールドのIDは自動設定(cf:ID)。未一致時は $BODY / $SUBJECT が利用できます。' 20 610 1090 45
$help.Anchor='Left,Right,Bottom';$help.ForeColor=(C $colors.muted);$tRules.Controls.Add($help)

$tPost.Controls.Add((Label 'MSGファイル(Classic Outlookが必要です)' 20 21 450))
$txtMsg=TextBox 20 51 660;$txtMsg.ReadOnly=$true;$tPost.Controls.Add($txtMsg)
$btnBrowse=Button 'ファイルを選択' 698 49 150;$tPost.Controls.Add($btnBrowse)
$btnPreview=Button '解析プレビュー' 865 49 170 'accent';$tPost.Controls.Add($btnPreview)
$y=105
foreach($spec in @(@('トラッカー',20),@('優先度',213),@('カテゴリ',406),@('ステータス',599),@('担当者',792))) {
    $tPost.Controls.Add((Label $spec[0] $spec[1] $y 170))
}
$cbTracker=Combo 20 132 180;$cbPriority=Combo 213 132 180;$cbCategory=Combo 406 132 180
$cbStatus=Combo 599 132 180;$cbAssignee=Combo 792 132 180
$tPost.Controls.AddRange(@($cbTracker,$cbPriority,$cbCategory,$cbStatus,$cbAssignee))
$tPost.Controls.Add((Label '対象バージョン' 20 178 165));$cbVersion=Combo 20 208 250;$tPost.Controls.Add($cbVersion)
$tPost.Controls.Add((Label '登録予定JSON(APIキーは含みません)' 20 250 600))
$txtPreview=TextBox 20 280 1090 310 $true;$txtPreview.ReadOnly=$true;$txtPreview.Anchor='Left,Top,Right,Bottom';$tPost.Controls.Add($txtPreview)
$btnSavePreview=Button 'JSONを保存' 20 610 165;$btnSavePreview.Anchor='Bottom,Left';$tPost.Controls.Add($btnSavePreview)
$btnPost=Button 'Redmineへ起票' 880 605 190 'good';$btnPost.Anchor='Bottom,Right';$tPost.Controls.Add($btnPost)
$lblPostResult=Label '確認後に「Redmineへ起票」を押してください。' 210 616 650 33
$lblPostResult.Anchor='Bottom,Left,Right';$lblPostResult.ForeColor=(C $colors.muted);$tPost.Controls.Add($lblPostResult)
foreach($cb in @($cbTracker,$cbPriority,$cbCategory,$cbStatus,$cbAssignee,$cbVersion)){Set-Options $cb @()}

$btnFetch.Add_Click({
    try {
        if($txtKey.Text){[Environment]::SetEnvironmentVariable('REDMINE_API_KEY',$txtKey.Text,'Process')}
        $url=Assert-RedmineConnection $txtUrl.Text $chkHttp.Checked
        $m=Get-RedmineProjectMetadata -BaseUrl $url -Project $txtProject.Text -AllowHttp:$chkHttp.Checked
        Write-JsonUtf8 $settingsFile @{baseUrl=$txtUrl.Text;project=$txtProject.Text;allowHttp=$chkHttp.Checked}
        Apply-Metadata $m
        Write-JsonUtf8 $metaFile $m
        Log 'プロジェクト情報をJSONに保存しました。'
    }catch{Fail $_}
})
$btnOpenMeta.Add_Click({
    try {
        $dlg=New-Object System.Windows.Forms.OpenFileDialog;$dlg.Filter='JSON (*.json)|*.json';$dlg.FileName='project-metadata.json'
        if($dlg.ShowDialog() -eq 'OK') { Apply-Metadata (Read-JsonUtf8 $dlg.FileName) }
    }catch{Fail $_}
})
$btnSaveMeta.Add_Click({
    try {
        if(-not $script:metadata){throw '保存する情報がありません。'}
        $dlg=New-Object System.Windows.Forms.SaveFileDialog;$dlg.Filter='JSON (*.json)|*.json';$dlg.FileName='project-metadata.json'
        if($dlg.ShowDialog() -eq 'OK'){Write-JsonUtf8 $dlg.FileName $script:metadata;Log 'プロジェクトJSONを保存しました。'}
    }catch{Fail $_}
})
$btnMapField.Add_Click({try{Add-FieldMapping}catch{Fail $_}})
$gridFields.Add_CellDoubleClick({param($sender,$e) if($e.RowIndex -ge 0){try{Add-FieldMapping}catch{Fail $_}}})
$btnAdd.Add_Click({
    try {
        Add-RuleToGrid ([pscustomobject]@{enabled=$true;name='新規ルール';source='Body';mode='Regex';pattern='';group=1;target='description';fallback=''})
        $gridRules.FirstDisplayedScrollingRowIndex=$gridRules.Rows.Count-1
    }catch{Fail $_}
})
$btnDelete.Add_Click({
    try {
        $indexes=@($gridRules.SelectedRows | ForEach-Object { $_.Index } | Sort-Object -Descending)
        if(-not $indexes.Count){throw '削除する行を選択してください。'}
        if([System.Windows.Forms.MessageBox]::Show("選択した $($indexes.Count) 行を削除しますか?",'確認','YesNo','Question') -ne 'Yes'){return}
        foreach($index in $indexes){$gridRules.Rows.RemoveAt([int]$index)}
    }catch{Fail $_}
})
$btnSaveRules.Add_Click({
    try {Write-JsonUtf8 $rulesFile (Get-RulesFromGrid);[System.Windows.Forms.MessageBox]::Show("ルールを保存しました: $rulesFile")|Out-Null}
    catch{Fail $_}
})
$btnReloadRules.Add_Click({try{Load-RulesGrid (Read-JsonUtf8 $rulesFile);Refresh-TargetList}catch{Fail $_}})
$btnBrowse.Add_Click({
    $dlg=New-Object System.Windows.Forms.OpenFileDialog;$dlg.Filter='Outlook MSG (*.msg)|*.msg'
    if($dlg.ShowDialog() -eq 'OK'){$txtMsg.Text=$dlg.FileName;$script:currentMail=$null;$script:payload=$null;$script:created=$false;$script:previewMsgHash='';$txtPreview.Clear();$lblPostResult.Text='MSGを選択しました。解析プレビューを実行してください。'}
})
$btnPreview.Add_Click({
    try {
        $script:currentMail=Get-MailFromMsg $txtMsg.Text
        $script:previewMsgHash=(Get-FileHash -LiteralPath $txtMsg.Text -Algorithm SHA256).Hash
        $script:payload=Prepare-Payload
        $txtPreview.Text=ConvertTo-Json -InputObject $script:payload -Depth 40
        $script:created=$false
        $lblPostResult.Text='プレビュー完了。内容を確認してから起票してください。'
        $tabs.SelectedTab=$tPost
    }catch{Fail $_}
})
$btnSavePreview.Add_Click({
    try {
        if(-not $script:payload){throw '先にプレビューしてください。'}
        $dlg=New-Object System.Windows.Forms.SaveFileDialog;$dlg.Filter='JSON (*.json)|*.json';$dlg.FileName='issue-preview.json'
        if($dlg.ShowDialog() -eq 'OK'){Write-JsonUtf8 $dlg.FileName $script:payload}
    }catch{Fail $_}
})
$btnPost.Add_Click({
    try {
        if($script:created){throw 'このMSGは既に起票しました。続けて起票する場合は別のMSGを選んでください。'}
        if(-not $script:payload){throw '必ず先に解析プレビューを実行してください。'}
        if((Get-FileHash -LiteralPath $txtMsg.Text -Algorithm SHA256).Hash -ne $script:previewMsgHash){throw 'プレビュー後にMSGファイルが変更されました。再解析してください。'}
        $sig=Get-PostSignature $txtUrl.Text ([int]$script:metadata.project.id) $txtMsg.Text
        $oldId=Find-PostedIssue $workspace $sig
        if($oldId){throw "同じMSGは起票済みです: #$oldId"}
        $base=Assert-RedmineConnection $txtUrl.Text $chkHttp.Checked
        if ($base -ne ([string]$script:metadata.baseUrl).TrimEnd('/')) {
            throw '現在のRedmine URLが読み込んだプロジェクトJSONの接続先と違います。正しいプロジェクト情報を再取得してください。'
        }
        $now=Prepare-Payload
        $jsonNow=ConvertTo-Json -InputObject $now -Depth 40 -Compress
        $jsonPrev=ConvertTo-Json -InputObject $script:payload -Depth 40 -Compress
        if($jsonNow -cne $jsonPrev){throw '入力がプレビュー後に変更されました。もう一度解析プレビューを実行してください。'}
        if($txtKey.Text){[Environment]::SetEnvironmentVariable('REDMINE_API_KEY',$txtKey.Text,'Process')}
        if([System.Windows.Forms.MessageBox]::Show("Redmine「$($script:metadata.project.name)」へ新規チケットを登録します。よろしいですか?",'起票確認','YesNo','Warning') -ne 'Yes'){return}
        $issue=Publish-RedmineIssue $txtUrl.Text $script:payload $chkHttp.Checked
        $script:created=$true
        try {Save-PostReceipt $workspace $sig $issue.id}
        catch {[System.Windows.Forms.MessageBox]::Show("チケット#$($issue.id)は作成されましたが履歴保存に失敗しました。次回起票前にRedmine側を確認してください。`n$($_.Exception.Message)",'履歴保存エラー')|Out-Null}
        $link=($txtUrl.Text.TrimEnd('/')+'/issues/'+$issue.id)
        $lblPostResult.Text="起票成功  #$($issue.id)  $link"
        [System.Windows.Forms.MessageBox]::Show("チケットを起票しました。`n$link",'起票完了')|Out-Null
    }catch{Fail $_}
})
try {
    if(Test-Path -LiteralPath $settingsFile){$prefs=Read-JsonUtf8 $settingsFile;$txtUrl.Text=[string]$prefs.baseUrl;$txtProject.Text=[string]$prefs.project;$chkHttp.Checked=[bool]$prefs.allowHttp}
    if(Test-Path -LiteralPath $rulesFile){Load-RulesGrid (Read-JsonUtf8 $rulesFile)}
    Refresh-TargetList
    if(Test-Path -LiteralPath $metaFile){Apply-Metadata (Read-JsonUtf8 $metaFile)}
} catch {Fail $_}
[void]$form.ShowDialog()

Redmine.Core.ps1

#Requires -Version 5.1

function Read-JsonUtf8([string]$Path) {
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "File not found: $Path" }
    return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json)
}
function Write-JsonUtf8([string]$Path, $Object) {
    $full = [System.IO.Path]::GetFullPath($Path)
    $folder = [System.IO.Path]::GetDirectoryName($full)
    if (-not [System.IO.Directory]::Exists($folder)) { [void][System.IO.Directory]::CreateDirectory($folder) }
    $json = ConvertTo-Json -InputObject $Object -Depth 40
    [System.IO.File]::WriteAllText($full, $json + "`r`n", (New-Object System.Text.UTF8Encoding($false)))
}
function Get-RedmineKey {
    $key = [Environment]::GetEnvironmentVariable('REDMINE_API_KEY', 'Process')
    if (-not $key) { $key = [Environment]::GetEnvironmentVariable('REDMINE_API_KEY','User') }
    if (-not $key) { throw 'REDMINE_API_KEY が未設定です。環境変数にAPIキーを登録してください。' }
    return $key
}
function Assert-RedmineConnection([string]$BaseUrl, [bool]$AllowHttp=$false) {
    $uri = $null
    if (-not [Uri]::TryCreate($BaseUrl, [UriKind]::Absolute, [ref]$uri)) { throw 'Redmine URLが不正です。' }
    if ($uri.Scheme -ne 'https' -and -not ($AllowHttp -and $uri.Scheme -eq 'http')) {
        throw 'APIキーを保護するためHTTPSのみ許可しています。HTTP環境では「HTTPを許可」を明示的に選択してください。'
    }
    if ($uri.UserInfo -or $uri.Query -or $uri.Fragment) { throw 'Redmine URLにログイン情報・クエリ・フラグメントを含めないでください。' }
    return $BaseUrl.TrimEnd('/')
}
function Invoke-RedmineApi {
    param([string]$BaseUrl,[string]$Path,[ValidateSet('GET','POST')]$Method='GET',$Body=$null,[bool]$AllowHttp=$false)
    $base = Assert-RedmineConnection $BaseUrl $AllowHttp
    $headers = @{'X-Redmine-API-Key' = (Get-RedmineKey); 'Accept' = 'application/json'}
    $uri = $base + '/' + $Path.TrimStart('/')
    try {
        if ($Method -eq 'POST') {
            $json = ConvertTo-Json -InputObject $Body -Depth 40 -Compress
            $bytes = [Text.Encoding]::UTF8.GetBytes($json)
            return Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 50 -ErrorAction Stop
        }
        return Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -TimeoutSec 35 -ErrorAction Stop
    }
    catch {
        $detail = $_.Exception.Message
        try {
            $response = $_.Exception.Response
            if ($null -ne $response) {
                $stream = $response.GetResponseStream()
                if ($stream) {
                    $reader = New-Object System.IO.StreamReader($stream)
                    $bodyText = $reader.ReadToEnd()
                    $reader.Dispose()
                    if ($bodyText) { $detail += ' / ' + $bodyText.Substring(0, [Math]::Min(1600,$bodyText.Length)) }
                }
            }
        } catch { }
        throw "Redmine API $Method $Path : $detail"
    }
}
function Get-RedmineProjectMetadata {
    param([string]$BaseUrl, [string]$Project, [bool]$AllowHttp=$false)
    if (-not $Project.Trim()) { throw 'プロジェクトIDまたは識別子を入力してください。' }
    $escaped = [Uri]::EscapeDataString($Project.Trim())
    $projectResult = Invoke-RedmineApi $BaseUrl "projects/$escaped.json?include=trackers,issue_categories,issue_custom_fields" -AllowHttp:$AllowHttp
    $p = $projectResult.project
    if (-not $p -or -not $p.id) { throw 'プロジェクトが見つかりません。' }
    $notes = New-Object System.Collections.Generic.List[string]
    $priorities=@(); $statuses=@(); $trackers=@(); $categories=@(); $custom=@(); $versions=@(); $members=@()
    if ($p.trackers) { $trackers = @($p.trackers) }
    if ($p.issue_categories) { $categories = @($p.issue_categories) }
    try { $priorities = @( (Invoke-RedmineApi $BaseUrl 'enumerations/issue_priorities.json' -AllowHttp:$AllowHttp).issue_priorities ) }
    catch { $notes.Add('優先度の取得に失敗: ' + $_.Exception.Message) }
    try { $statuses = @( (Invoke-RedmineApi $BaseUrl 'issue_statuses.json' -AllowHttp:$AllowHttp).issue_statuses ) }
    catch { $notes.Add('ステータスの取得に失敗: ' + $_.Exception.Message) }
    try {
        $allTrackers = @((Invoke-RedmineApi $BaseUrl 'trackers.json' -AllowHttp:$AllowHttp).trackers)
        if (-not $trackers.Count) { $trackers=$allTrackers }
        elseif ($allTrackers.Count) {
            $ids = @($trackers | ForEach-Object { [int]$_.id })
            $trackers = @($allTrackers | Where-Object { $ids -contains [int]$_.id })
        }
    } catch { $notes.Add('トラッカーの詳細取得に失敗: ' + $_.Exception.Message) }
    try { $versions = @((Invoke-RedmineApi $BaseUrl "projects/$escaped/versions.json" -AllowHttp:$AllowHttp).versions) }
    catch { $notes.Add('対象バージョンの取得を省略: ' + $_.Exception.Message) }
    try {
        $offset=0
        do {
            $m=(Invoke-RedmineApi $BaseUrl "projects/$escaped/memberships.json?offset=$offset&limit=100" -AllowHttp:$AllowHttp)
            foreach ($entry in @($m.memberships)) {
                if ($entry.user -and $entry.user.id) { $members += [pscustomobject]@{id=$entry.user.id;name=$entry.user.name} }
            }
            $offset += @($m.memberships).Count
        } while ($offset -lt [int]$m.total_count -and @($m.memberships).Count -gt 0)
        $members = @($members | Sort-Object id -Unique)
    } catch { $notes.Add('担当者候補の取得を省略: ' + $_.Exception.Message) }
    try {
        $allCustom = @((Invoke-RedmineApi $BaseUrl 'custom_fields.json' -AllowHttp:$AllowHttp).custom_fields |
            Where-Object { $_.customized_type -eq 'issue' })
        $projFieldIds = @($p.issue_custom_fields | ForEach-Object { [int]$_.id })
        # プロジェクトが返す一覧にない「全プロジェクト共通」の項目は管理者APIの定義から補う。
        $custom = @($allCustom | Where-Object {
            ($projFieldIds -contains [int]$_.id) -or $_.is_for_all -eq $true -or $_.is_for_all -eq 1
        })
        # 全プロジェクト共通のフラグを返さない構成では、プロジェクトから見える項目を優先。
        if (-not $custom.Count -and $projFieldIds.Count) {
            $custom = @($allCustom | Where-Object { $projFieldIds -contains [int]$_.id })
        }
        if (-not $custom.Count -and -not $projFieldIds.Count) {
            $notes.Add('カスタムフィールドが取得できませんでした。全プロジェクト共通フィールドがある場合は対象プロジェクトで確認してください。')
        }
    } catch {
        $custom = @($p.issue_custom_fields)
        $notes.Add('管理者権限が必要な /custom_fields.json にアクセスできないため、プロジェクトの公開範囲のみ使用しています。形式や候補値は取得できない場合があります。')
    }
    return [pscustomobject]@{
        schemaVersion=1
        exportedAt=(Get-Date).ToString('o')
        baseUrl=$BaseUrl.TrimEnd('/')
        project=[pscustomobject]@{id=[int]$p.id;identifier=[string]$p.identifier;name=[string]$p.name}
        trackers=@($trackers); priorities=@($priorities); statuses=@($statuses)
        categories=@($categories); versions=@($versions); members=@($members)
        customFields=@($custom); warnings=@($notes.ToArray())
    }
}
function Get-MailFromMsg([string]$Path) {
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "MSGが見つかりません: $Path" }
    $outlook=$null; $item=$null
    try {
        $outlook = New-Object -ComObject Outlook.Application
        $item = $outlook.Session.OpenSharedItem([System.IO.Path]::GetFullPath($Path))
        if ([int]$item.Class -ne 43) { throw 'MailItem形式の.msgではありません。' }
        $received=''
        try { $received=([datetime]$item.ReceivedTime).ToString('yyyy-MM-dd HH:mm:ss') } catch { }
        return [pscustomobject]@{
            Subject=[string]$item.Subject
            Body=[string]$item.Body
            SenderName=[string]$item.SenderName
            SenderEmailAddress=[string]$item.SenderEmailAddress
            To=[string]$item.To
            ReceivedTime=$received
        }
    } finally {
        if ($item) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($item) }
        if ($outlook) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($outlook) }
    }
}
function Get-MailSource($Mail,[string]$Source) {
    switch ($Source) {
        'Subject' { return [string]$Mail.Subject }
        'Body' { return [string]$Mail.Body }
        'Subject+Body' { return ([string]$Mail.Subject + "`n" + [string]$Mail.Body) }
        'SenderName' { return [string]$Mail.SenderName }
        'SenderEmailAddress' { return [string]$Mail.SenderEmailAddress }
        'To' { return [string]$Mail.To }
        'ReceivedTime' { return [string]$Mail.ReceivedTime }
        default { throw "未知の抽出元: $Source" }
    }
}
function Get-RuleValue($Mail,$Rule) {
    $src = [string](Get-MailSource $Mail ([string]$Rule.source))
    $pattern=[string]$Rule.pattern
    $value=$null
    switch ([string]$Rule.mode) {
        'Regex' {
            if (-not $pattern) { throw "ルール「$($Rule.name)」に正規表現が設定されていません" }
            $rx=[System.Text.RegularExpressions.Regex]::new($pattern,([System.Text.RegularExpressions.RegexOptions]::Multiline -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase),[TimeSpan]::FromSeconds(2))
            $m=$rx.Match($src)
            $group=0; if ($null -ne $Rule.group -and "$($Rule.group)" -match '^\d+$') { $group=[int]$Rule.group }
            if ($m.Success -and $group -lt $m.Groups.Count) { $value=$m.Groups[$group].Value }
        }
        'Keywords' {
            $found=@($pattern.Split([char]'|') | Where-Object { $_.Trim() -ne '' -and $src.IndexOf($_.Trim(),[System.StringComparison]::OrdinalIgnoreCase) -ge 0 } | ForEach-Object { $_.Trim() })
            if ($found.Count) { $value=($found -join '、') }
        }
        'Literal' {
            if (-not $pattern) { $value=$src }
            elseif ($src.IndexOf($pattern,[System.StringComparison]::OrdinalIgnoreCase) -ge 0) { $value=$pattern }
        }
        'Constant' { $value=$pattern }
        default { throw "未知の抽出方式: $($Rule.mode)" }
    }
    if ([string]::IsNullOrWhiteSpace([string]$value)) {
        switch ([string]$Rule.fallback) {
            '$BODY' { $value=[string]$Mail.Body }
            '$SUBJECT' { $value=[string]$Mail.Subject }
            '$SENDER' { $value=[string]$Mail.SenderName }
            default { $value=[string]$Rule.fallback }
        }
    }
    return [string]$value
}
function New-RedmineIssueBody {
    param($Mail,$Metadata,$Rules,[int]$TrackerId=0,[int]$PriorityId=0,[int]$CategoryId=0,[int]$StatusId=0,[int]$AssignedToId=0,[int]$VersionId=0)
    $issue=[ordered]@{ project_id=[int]$Metadata.project.id; subject=[string]$Mail.Subject; description=[string]$Mail.Body }
    foreach ($pair in @(@('tracker_id',$TrackerId),@('priority_id',$PriorityId),@('category_id',$CategoryId),@('status_id',$StatusId),@('assigned_to_id',$AssignedToId),@('fixed_version_id',$VersionId))) {
        if ([int]$pair[1] -gt 0) { $issue[[string]$pair[0]]=[int]$pair[1] }
    }
    $cfs=@{}
    foreach ($rule in @($Rules.rules)) {
        if ($null -eq $rule -or $rule.enabled -eq $false) { continue }
        $target=[string]$rule.target
        if ([string]::IsNullOrWhiteSpace($target) -or $target -eq 'Ignore') { continue }
        $value=[string](Get-RuleValue $Mail $rule)
        if (-not $value.Trim()) { continue }
        if ($target -match '^cf:(\d+)$') {
            $id=[int]$Matches[1]
            $def=@($Metadata.customFields | Where-Object { [int]$_.id -eq $id } | Select-Object -First 1)
            if (-not $def.Count) { throw "カスタムフィールドID=$id はプロジェクト定義にありません" }
            if ($def[0].multiple -eq $true) {
                $cfs[$id]=@($value -split '\|' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
            } else { $cfs[$id]=$value }
        }
        elseif ($target -in @('subject','description')) { $issue[$target]=$value }
        elseif ($target -in @('tracker_id','priority_id','category_id','status_id','assigned_to_id','fixed_version_id','parent_issue_id')) {
            if ($value -notmatch '^\d+$') { throw "「$target」の値は数値IDが必要です: $value" }
            $issue[$target]=[int]$value
        }
        elseif ($target -in @('due_date','start_date')) {
            $date=[datetime]::MinValue
            if (-not [datetime]::TryParse($value,[ref]$date)) { throw "日付形式が不正です: $value" }
            $issue[$target]=$date.ToString('yyyy-MM-dd')
        }
        else { throw "未知の出力先: $target" }
    }
    if ([string]::IsNullOrWhiteSpace($issue.subject)) { throw 'チケット件名が空です。' }
    if (-not $issue.Contains('tracker_id') -and @($Metadata.trackers).Count) { $issue['tracker_id']=[int]$Metadata.trackers[0].id }
    if ($cfs.Count) { $issue['custom_fields']=@($cfs.Keys | Sort-Object | ForEach-Object { @{id=[int]$_;value=$cfs[$_]} }) }
    foreach ($def in @($Metadata.customFields)) {
        if ($def.is_required -eq $true) {
            $allowedTrackers=@($def.trackers | ForEach-Object { [int]$_.id })
            if ($allowedTrackers.Count -and $issue['tracker_id'] -notin $allowedTrackers) { continue }
            $id=[int]$def.id
            if (-not $cfs.ContainsKey($id)) { throw "必須カスタムフィールド「$($def.name)」(ID=$id)が未設定です。抽出ルールを追加してください。" }
        }
    }
    return @{issue=$issue}
}
function Publish-RedmineIssue($BaseUrl,$Payload,[bool]$AllowHttp=$false) {
    $created=Invoke-RedmineApi -BaseUrl $BaseUrl -Path 'issues.json' -Method POST -Body $Payload -AllowHttp:$AllowHttp
    if (-not $created.issue -or -not $created.issue.id) { throw 'APIは応答しましたがチケットIDを取得できませんでした。重複登録防止のため、Redmine側の登録状況を確認してください。' }
    return $created.issue
}

function Get-PostSignature([string]$BaseUrl,[int]$ProjectId,[string]$MsgPath) {
    $hash=(Get-FileHash -LiteralPath $MsgPath -Algorithm SHA256).Hash
    return ($BaseUrl.TrimEnd('/').ToLowerInvariant() + '|' + $ProjectId + '|' + $hash)
}
function Find-PostedIssue([string]$Workspace,[string]$Signature) {
    $path=Join-Path $Workspace 'posted-history.json'
    if (-not (Test-Path -LiteralPath $path)) {return $null}
    $history=Read-JsonUtf8 $path
    $item=@($history.entries | Where-Object { $_.signature -ceq $Signature } | Select-Object -First 1)
    if ($item.Count) {return $item[0].issueId}
    return $null
}
function Save-PostReceipt([string]$Workspace,[string]$Signature,$IssueId) {
    $path=Join-Path $Workspace 'posted-history.json'
    $history=[ordered]@{schemaVersion=1;entries=@()}
    if (Test-Path -LiteralPath $path) {$history=Read-JsonUtf8 $path}
    $existing=@($history.entries | Where-Object {$_.signature -ceq $Signature})
    if (-not $existing.Count) {
        $entries=@($history.entries) + @([pscustomobject]@{signature=$Signature;issueId=[int]$IssueId;createdAt=(Get-Date).ToString('o')})
        Write-JsonUtf8 $path ([pscustomobject]@{schemaVersion=1;entries=$entries})
    }
}

Fetch-ProjectInfo.bat

@echo off
cd /d "%~dp0"
echo Run this in PowerShell with Redmine URL and Project identifier:
echo .\Fetch-ProjectInfo.ps1 -BaseUrl https://redmine.example.com -Project sample-project
pause

Fetch-ProjectInfo.ps1

#Requires -Version 5.1
[CmdletBinding()]
param(
    [Parameter(Mandatory=$true)][string]$BaseUrl,
    [Parameter(Mandatory=$true)][string]$Project,
    [string]$OutputPath=(Join-Path $PSScriptRoot 'project-metadata.json'),
    [switch]$AllowHttp
)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'Redmine.Core.ps1')
try {
    $meta=Get-RedmineProjectMetadata -BaseUrl $BaseUrl -Project $Project -AllowHttp:$AllowHttp.IsPresent
    Write-JsonUtf8 $OutputPath $meta
    Write-Host "プロジェクト情報の取得に成功: $($meta.project.name)" -ForegroundColor Green
    Write-Host "保存先: $OutputPath"
    Write-Host "トラッカー $(@($meta.trackers).Count) 件、カスタムフィールド $(@($meta.customFields).Count) 件"
    foreach($warn in @($meta.warnings)){if($warn){Write-Warning $warn}}
    exit 0
} catch {
    Write-Error $_.Exception.Message
    exit 2
}

Run-Issue.ps1

#Requires -Version 5.1
[CmdletBinding()]
param(
    [string]$MsgPath,
    [string]$MailJson,
    [string]$MetadataPath=(Join-Path $PSScriptRoot 'project-metadata.json'),
    [string]$RulesPath=(Join-Path $PSScriptRoot 'rules.json'),
    [string]$BaseUrl,
    [int]$TrackerId=0,
    [int]$PriorityId=0,
    [int]$StatusId=0,
    [string]$OutputPath,
    [switch]$CreateIssue,
    [switch]$AllowHttp
)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'Redmine.Core.ps1')
try {
    if ([bool]$MsgPath -eq [bool]$MailJson) { throw '-MsgPath または -MailJson のどちらか1つを指定してください。' }
    $meta=Read-JsonUtf8 $MetadataPath
    $rules=Read-JsonUtf8 $RulesPath
    if($MsgPath){$mail=Get-MailFromMsg $MsgPath}else{$mail=Read-JsonUtf8 $MailJson}
    $payload=New-RedmineIssueBody -Mail $mail -Metadata $meta -Rules $rules -TrackerId $TrackerId -PriorityId $PriorityId -StatusId $StatusId
    $json=ConvertTo-Json -InputObject $payload -Depth 40
    Write-Host $json
    if($OutputPath){Write-JsonUtf8 $OutputPath $payload;Write-Host "JSON保存: $OutputPath"}
    if(-not $CreateIssue){Write-Host 'プレビューモードです。チケットは登録していません。起票には -CreateIssue を付けてください。';exit 0}
    if($MailJson){throw '安全のためMailJsonテスト入力からの本番起票は無効です。MSGファイルを指定してください。'}
    if(-not $BaseUrl){$BaseUrl=[string]$meta.baseUrl}
    $base=Assert-RedmineConnection $BaseUrl $AllowHttp.IsPresent
    if($base.TrimEnd('/') -ne ([string]$meta.baseUrl).TrimEnd('/')) { throw 'メタデータの接続先と指定BaseUrlが一致しません。プロジェクト情報を更新してください。' }
    $signature=Get-PostSignature $base ([int]$meta.project.id) $MsgPath
    $oldId=Find-PostedIssue $PSScriptRoot $signature
    if($oldId){throw "同じMSGは既に起票済みです: #$oldId"}
    $issue=Publish-RedmineIssue $base $payload $AllowHttp.IsPresent
    try { Save-PostReceipt $PSScriptRoot $signature $issue.id }
    catch { Write-Warning "チケット#$($issue.id)は起票済みですが履歴保存に失敗しました。再起票しないでください: $($_.Exception.Message)" }
    Write-Host "起票成功: $base/issues/$($issue.id)" -ForegroundColor Green
    exit 0
} catch {
    Write-Error $_.Exception.Message
    exit 1
}

rules.json

{
  "version": 1,
  "rules": [
    {
      "enabled": true,
      "name": "問い合わせ者",
      "source": "Body",
      "mode": "Regex",
      "pattern": "(?m)^問い合わせ者[::]\\s*(.+)\\s*$",
      "group": 1,
      "target": "cf:12",
      "fallback": "$SENDER"
    },
    {
      "enabled": true,
      "name": "外部チケット番号",
      "source": "Subject+Body",
      "mode": "Regex",
      "pattern": "\\b(?:INC|REQ|TCK)-\\d{4}-\\d+\\b",
      "group": 0,
      "target": "cf:13",
      "fallback": ""
    },
    {
      "enabled": true,
      "name": "問い合わせ分類",
      "source": "Body",
      "mode": "Regex",
      "pattern": "(?m)^分類[::]\\s*(障害|質問|依頼)\\s*$",
      "group": 1,
      "target": "cf:15",
      "fallback": "質問"
    }
  ]
}

sample-project-metadata.json

{
  "schemaVersion": 1,
  "exportedAt": "2026-10-14T00:00:00+09:00",
  "baseUrl": "https://redmine.example.com",
  "project": {
    "id": 246,
    "identifier": "sample-project",
    "name": "サンプルプロジェクト(オフライン確認用)"
  },
  "trackers": [
    {
      "id": 1,
      "name": "バグ",
      "default_status": {
        "id": 1,
        "name": "新規"
      }
    },
    {
      "id": 2,
      "name": "問い合わせ",
      "default_status": {
        "id": 1,
        "name": "新規"
      }
    }
  ],
  "priorities": [
    {
      "id": 2,
      "name": "通常"
    },
    {
      "id": 3,
      "name": "高"
    }
  ],
  "statuses": [
    {
      "id": 1,
      "name": "新規"
    },
    {
      "id": 2,
      "name": "進行中"
    },
    {
      "id": 3,
      "name": "解決"
    }
  ],
  "categories": [
    {
      "id": 7,
      "name": "アプリケーション"
    },
    {
      "id": 8,
      "name": "ネットワーク"
    }
  ],
  "versions": [],
  "members": [
    {
      "id": 5,
      "name": "山田 太郎"
    },
    {
      "id": 6,
      "name": "佐藤 花子"
    }
  ],
  "customFields": [
    {
      "id": 12,
      "name": "問い合わせ者",
      "customized_type": "issue",
      "field_format": "string",
      "is_required": true,
      "multiple": false,
      "possible_values": []
    },
    {
      "id": 13,
      "name": "外部チケット番号",
      "customized_type": "issue",
      "field_format": "string",
      "is_required": false,
      "multiple": false,
      "possible_values": []
    },
    {
      "id": 15,
      "name": "問い合わせ分類",
      "customized_type": "issue",
      "field_format": "list",
      "is_required": false,
      "multiple": false,
      "possible_values": [
        {
          "value": "障害"
        },
        {
          "value": "質問"
        },
        {
          "value": "依頼"
        }
      ]
    }
  ],
  "warnings": [
    "これはオフライン確認用の例です。実際のチケット登録前にRedmineからプロジェクト定義を取得してください。"
  ]
}

test-mail.json

{
  "Subject": "【障害】ログインできない INC-2026-001",
  "Body": "問い合わせ者: 山田 太郎\n分類: 障害\n\n業務システムへログインできず、エラーが発生します。",
  "SenderName": "山田 太郎",
  "SenderEmailAddress": "[email protected]",
  "To": "[email protected]",
  "ReceivedTime": "2026-10-14 09:30:00"
}

tool-settings.json

{
  "baseUrl": "https://redmine.example.com",
  "project": "sample-project",
  "allowHttp": false
}

9. トラブルシューティング

症状 確認箇所
GUIが起動しない Windows PowerShell 5.1で起動しているか、.ps1 の文字コード、実行ポリシー
.msg が読めない Classic Outlookのインストール、既定プロファイル、MailItem形式か
プロジェクト情報が取得できない URL、プロジェクト識別子、APIキー、REST APIの有効化
カスタムフィールドが不足 管理者APIの参照権限、Redmine側のフィールド設定、プロジェクト割当て
422で登録失敗 必須カスタムフィールド、正しい選択肢、トラッカー・ステータス権限
同じファイルが登録できない posted-history.json と既存チケットを確認。履歴を安易に削除しない
サンプルJSONでは動作するが本番起票できない 実プロジェクトのメタデータと抽出ルールの cf:ID を照合

10. まとめ

プロジェクトごとに異なるトラッカーやカスタムフィールドの情報をJSONへ切り出すことで、MSG解析のロジックとRedmineへの項目マッピングを分離できます。GUIでルールを整え、送信JSONを確認し、明示操作で起票する流れにしておくと、登録先の取り違えも防ぎやすくなります。

実務では、まずサンプルJSONで抽出ルールを試し、次に検証用プロジェクトから実際の情報を取得し、最後にRedmine側の起票結果を確認する順番が適しています。

参考資料

Previous Post Next Post